問題解説: WebRTC 実技

問題文

「WebRTC」

この問題はトラブルが複数に分かれています。
その場合、回答本文に「どちらのトラブルについての回答・質問か」を明記してください。
ある社内のコミュニケーションツールとして、WebRTCを利用したテキストチャット・ビデオ共有ツールを導入しています。
あなたはこの社員と協力し、以下のトラブルを解決することになりました。

情報

  • この問題で使用するブラウザは「Google Chrome」と「Firefox」のみです。
  • これら以外のブラウザでは問題回答ができませんので、このブラウザで動作確認をしてください。
  • 参加者は自身のPCからVNCサーバーにHTTPSでWebRTCサービスにアクセスしてください。
  • WebRTCサービスではHTTPS通信に自己署名証明書を使用をしております。ブラウザからアクセスした際に証明書の警告がされます。

問題1 Firefoxで動作しない。

Firefoxにてビデオチャットが動作しないトラブルが発生しています。
このトラブルが発生する原因を調べ、原因の報告、Firefoxにて動作するよう修正を行ってください。

問題2 テキストチャットが動作しない。

クライアント同士が接続後にWebRTC上の通信でテキストチャットを行おうとしたが動作しないトラブルが発生しています。
このトラブルが発生する原因を調べ、原因の報告、テキストチャットが動作するよう修正を行ってください。

サーバーへのアクセス情報

踏み台サーバーから以下のサーバーにアクセスすることができます。

1. WebRTC Server
Address: 192.168.0.100
User: admin
Password: vcFkyv3u
WebRTCのExpress Serverは systemd にて管理
systemctl start ictsc-chat で起動

ゴール

問題1. Firefoxで動作しない

Firefoxでビデオチャットを動作するようにする

問題2. テキストチャットが動作しない。

正しくテキストチャットが動作するようにする

トラブルの概要

[問題1]

~/server/assets/main.jsにて古いAPIが使用されている為に発生するトラブルです。

[問題2]

offer SDPにData Channelの情報が含まれない為に発生するトラブルです。

解説

[問題1]

今回のプログラムをFirefoxで動かすと、コンソールに
TypeError: navigator.getUserMedia is not a function[詳細]
と表示されている事が確認できます。
メディアストリームを取得するAPIで Navigator.getUserMedia が使用されていますが、現在では非推奨となっており、Firefoxでは予選開催日(8月25日)現在で未対応であるため発生するトラブルです。
このAPIの代替APIであり、FirefoxとGoogle Chrome双方で対応しているMediaDevices.getUserMedia を使用するように修正することでこの問題を解決できます。

[問題2]

RTCPeerConnection.createDataChannel
RTCPeerConnection.createOffer 後に行った場合、
offer SDPに Data Channel の情報が乗らず、
Data Channelでの通信がクライアント同士で行われない故に発生するトラブルです。

RTCPeerConnection.createOffer にてoffer SDPを生成する前に
RTCPeerConnection.createDataChannelを使用するように修正することでこの問題を解決できます。

今回のトラブルでは、

クライアント同士が接続後にWebRTC上の通信でテキストチャットを行おうとしたが動作しないトラブルが発生しています。

上記の問題文にある通り、WebRTC上の通信にてテキストチャットを行えるよう修正する問題ですので、シグナリングサーバーのプログラムである ~/server/app.js ファイルを修正しての回答は減点しております。

解答例

[問題1]

~/server/assets/main.js ファイル の21行目にある
navigator.getUserMedia APIを使用している行の記述を変更します。

  • 変更前
    navigator
      .getUserMedia(
        {
          video: true,
          audio: false
        },
        stream => {
          lms = stream;
          const video = addVideo("local");
          video.srcObject = lms;
          video.play();
          socket.send({ type: "call" });
        },
        e => console.error(e)
      );
  • 変更後
    navigator.mediaDevices.getUserMedia({ video: true, audio: false })
        .then(stream => {
          lms = stream;
          const video = addVideo("local");
          video.srcObject = lms;
          video.play();
          socket.send({ type: "call" });
        })
        .catch(e => console.error(e));

[問題2]

~/server/assets/main.js 114, 115行目

const channel = peer.createDataChannel("datachannel");
channel.onmessage = handleRTCData(id);


const offer = await peer.createOffer();の前に移動。

  • 変更前
...
const offer = await peer.createOffer();
await peer.setLocalDescription(new RTCSessionDescription(offer));
sendData({ type: "sdp", data: offer }, id);

const channel = peer.createDataChannel("datachannel");
channel.onmessage = handleRTCData(id);

...
  • 変更後
...
const channel = peer.createDataChannel("datachannel");
channel.onmessage = handleRTCData(id);

const offer = await peer.createOffer();
await peer.setLocalDescription(new RTCSessionDescription(offer));
sendData({ type: "sdp", data: offer }, id);
...

講評

この問題の作成を担当した杉山です。第一予選お疲れ様でした!
WebRTC問題の結果になります。

配点: 500点
問1: 30%
問2: 70%
回答チーム数: 12
問1正解チーム数: 3
問2正解チーム数: 0
※回答によっては部分点として配点しています。

ICTSCでソースコードを書き換える問題は今までに出題されたことがありますが、フロントエンド側の問題としては初めてでした。

問1に関しては、エラーログをコンソールで見れば問題箇所はすぐにわかりますので、そこからMDNなどのサイトを確認すればすぐに修正できたかと思います。
問2に関しては、
1. ~/server/assets/main.js にて sendData 関数を確認し、Data Channelが有効な場合 user.channel.send が呼ばれることを確認
2. SDP negotiationをデバッグし、offer SDPにData Channelの情報が含まれていないことを確認
3. RTCPeerConnection.createOffer にてoffer SDPを生成した後に RTCPeerConnection.createDataChannelにてData Channelが生成されていることを確認
上記の手順を踏めば問題を修正できたかと思います。

今回の問題ではWebRTCを題材とした問題を出題しましたが、想定していたよりもWebRTCの問題の部分に触れてくれるチームが少なかったと感じています。

WebRTCは様々な技術が内部で使用されている魅力的な技術なので、是非調べて挑戦してみてください!

ソースコード

~/server/app.js

const express = require('express');
const app = express();
const http = require("http").Server(app);
const io = require("socket.io")(http);
const PORT = 8080;

app.use(express.static("assets"));

io.on("connection", (socket) => {
  let roomName = null;
  socket.on("enter", (x) => {
    roomName = x;
    socket.join(roomName);
  });

  socket.on("message", (message) => {
    message.from = socket.id;

    if (message.type != "call" && message.type != "sdp" && message.type != "candidate" && message.type != "bye") {
      return;
    }

    if (message.sendTo) {
      socket.to(message.sendTo).json.emit("message", message);
      return;
    }

    if (roomName) socket.broadcast.to(roomName).emit("message", message);
    else socket.broadcast.emit("message", message);
  });

  socket.on("disconnect", () => {
    if (roomName) socket.broadcast.to(roomName).emit("message", { from: socket.id, type: "bye"});
    else socket.broadcast.emit("message", { from: socket.id, type: "bye"});
  });
});

http.listen(PORT);

~/server/assets/index.html

<!doctype html>
<html>
<head>
  <title>ICTSC Chat</title>
  <meta charset="utf-8">
  <link rel="stylesheet" href="main.css" type="text/css" media="all">
</head>
<body>
  <main>
    <div class="chat-widget">
      <div class="control-box">
        <input type="text" id="room-name" placeholder="Room name"
               inputmode="latin" size=15 maxlength=10>
        <button id="connect-button">
          Connect
        </button>
      </div>
      <div class="message-box" id="message-box">
      </div>
      <div class="chat-box">
        <input type="text" id="message" placeholder="Message text"
               inputmode="latin" size=40 maxlength=120 disabled>
        <button id="send-button" disabled>
          Send
        </button>
      </div>
    </div>
    <div class="webrtc-media" id="webrtc-media"></div>
  </main>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.slim.js"></script>
  <script src="./main.js"></script>
</body>
</html>

~/server/assets/main.js

const socket = io.connect(location.origin);

const connectButton = document.getElementById("connect-button");
const sendButton = document.getElementById("send-button");
const messageInputBox = document.getElementById("message");
const messageBox = document.getElementById("message-box");
const roomName = document.getElementById("room-name");
const webrtcMedia = document.getElementById("webrtc-media");

let users = [];
let lms = null; // localmediastream

const states = {
  get connected() {
    return this._connected;
  },
  // handler for state change
  async connect() {
    this._connected = true;
    socket.emit("enter", roomName.value ? roomName.value : "_default");
    navigator
      .getUserMedia(
        {
          video: true,
          audio: false
        },
        stream => {
          lms = stream;
          const video = addVideo("local");
          video.srcObject = lms;
          video.play();
          socket.send({ type: "call" });
        },
        e => console.error(e)
      );
    connectButton.innerText = "Disconnect";
    roomName.disabled = true;
    sendButton.disabled = false;

    messageInputBox.value = "";
    messageInputBox.disabled = false;
  },
  disconnect() {
    this._connected = false;
    connectButton.innerText = "Connect";
    roomName.disabled = false;
    sendButton.disabled = true;

    messageInputBox.value = "";
    messageInputBox.disabled = true;

    delAllVideo();

    if (users.length !== 0) {
      socket.send({ type: "bye" });
      users.forEach(user => {
        user.channel && user.channel.close();
        user.peer.close();
      });
      users = [];
    }
    lms = null;
  }
}

const createPeer = id => {
  const peer = new RTCPeerConnection({
    iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
  });

  peer.onicecandidate = event => sendData({ type: "candidate", data: event.candidate }, id);
  peer.ontrack = e => e.streams[0] && addRemoteVideo(id, e.streams[0]);

  return peer;
};

// automatically choose socket or datachannel and send
const sendData = (data, id) => {
  const user = users.find(x => x.id === id);

  if (user && user.channel && user.channel.readyState === "open") {
    user.channel.send(JSON.stringify(data));
  } else {
    data.sendTo = id;
    socket.send(data);
  }
};

const handleSocketData = data => {
  handleData(data.from, data);
};

const handleRTCData = id => message => {
  handleData(id, JSON.parse(message.data));
};

// generic handler for socket and datachannel
const handleData = async (id,  obj) => {
  if (!states.connected) return;
  const type = obj.type;
  const data = obj.data;

  if (type === "call") {
    const peer = createPeer(id);

    for (const track of lms.getVideoTracks()) {
      peer.addTrack(track, lms);
    }

    const offer = await peer.createOffer();
    await peer.setLocalDescription(new RTCSessionDescription(offer));
    sendData({ type: "sdp", data: offer }, id);

    const channel = peer.createDataChannel("datachannel");
    channel.onmessage = handleRTCData(id);

    users = users.concat({
      id,
      channel,
      peer
    });
  } else if (type === "sdp") {
    const sdp = data;
    // new RTC connection
    if (sdp.type === "offer") {
      const peer = createPeer(id);
      const user = { id, peer };

      peer.ondatachannel = async event => {
        const channel = event.channel;
        const label = channel.label;

        channel.onmessage = handleRTCData(id);

        users = users.map(x => {
          if (x.id === id) {
            x.channel = channel;
          }
          return x;
        });
      };

      for (const track of lms.getVideoTracks()) {
        peer.addTrack(track, lms);
      }
      await peer.setRemoteDescription(new RTCSessionDescription(sdp));
      const answer = await peer.createAnswer();
      await peer.setLocalDescription(new RTCSessionDescription(answer))
      sendData({ type: "sdp", data: answer }, user.id);

      users = users.concat(user);
    } else if (sdp.type == "answer") {
      const user = users.find(x => x.id === id);
      user.peer.setRemoteDescription(new RTCSessionDescription(sdp));
    }
  } else if (type === "candidate") {
    const user = users.find(x => x.id === id);
    const candidate = data;
    if (user && candidate) user.peer.addIceCandidate(candidate);
  } else if (type === "chat") {
    handleMessage(id, data);
  } else if (type === "bye") {
    const user = users.find(x => x.id === id);
    if (user) {
      user.channel && user.channel.close();
      user.peer.close();
      users = users.filter(x => x.id !== id);
      delVideo(`video-${id}`);
    }
  } else {
    console.error(`unhandled data:${type}`, data);
  }
};

// media chat handler
const addRemoteVideo = (id, stream) => {
  const video = addVideo(`video-${id}`);
  stream.onremovetrack = () => {
    delVideo(`video-${id}`);
  };
  video.srcObject = stream;
  video.play();
};

const addVideo = id => {
  let video = document.getElementById(id);
  if (video) return video;
  video = document.createElement("video");
  video.id = id;
  video.width = 160;
  webrtcMedia.appendChild(video);
  return video;
};

const delVideo = id => {
  const video = document.getElementById(id);
  if (!video) return null;
  if (video) return webrtcMedia.removeChild(video);
};

const delAllVideo = () => {
  while (webrtcMedia.firstChild)
    webrtcMedia.removeChild(webrtcMedia.firstChild);
}

// chat message handler
const handleMessage = (id, message) => {
  const el = document.createElement("div");
  el.className = "message received-message";
  const nameEl = document.createElement("span");
  const balloonEl = document.createElement("p");
  nameEl.textContent = id;
  balloonEl.textContent = message;
  el.appendChild(nameEl);
  el.appendChild(balloonEl);
  const needsScroll =
    messageBox.scrollTop + messageBox.clientHeight === messageBox.scrollHeight;
  messageBox.appendChild(el);
  if (needsScroll)
    messageBox.scrollTop = messageBox.scrollHeight - messageBox.clientHeight;
};

const appendMyMessage = message => {
  const el = document.createElement("div");
  el.className = "message my-message";
  const balloonEl = document.createElement("p");
  balloonEl.textContent = message;
  el.appendChild(balloonEl);
  messageBox.appendChild(el);
  messageBox.scrollTop = messageBox.scrollHeight - messageBox.clientHeight;
};

// add event handlers for each button
connectButton.addEventListener("click", () => {
  if (!states.connected)
    states.connect();
  else
    states.disconnect();
});

sendButton.addEventListener(
  "click",
  () => {
    const message = messageInputBox.value;
    if (message) {
      for (const user of users)
        sendData({ type: "chat", data: message }, user.id);

      appendMyMessage(message);
      messageInputBox.value = "";
      messageInputBox.focus();
    }
  },
  false
);

socket.on("message", handleSocketData);

~/server/assets/main.css

html {
  height: 100%;
}

body {
  margin: 0;
  font-family: "Lucida Grande", "Arial", sans-serif;
  font-size: 16px;
  display: flex;
  height: 100%;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: linear-gradient(to top, #bbd0d5, #d1dbdd);
}

button + button {
  margin-left: 8px;
}

button {
  border: none;
  display: inline-block;
  padding: 7px 20px;
  border-radius: 25px;
  text-decoration: none;
  color: #FFF;
  background-image: linear-gradient(45deg, #FFC107 0%, #ff8b5f 100%);
  transition: all .4s ease-out;
  cursor: pointer;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
}

button:active {
  background-image: linear-gradient(45deg, #FFC107 0%, #f76a35 100%);
}

button:disabled {
  color: #eee;
  background: #bbb;
  cursor: default;
  box-shadow: none;
}

main {
  background-color: #fafbfd;
  border-radius: 8px;
  box-shadow: 3px 3px 20px 9px rgba(0, 0, 0, .3);
  display: flex;
  height: 480px;
}

video {
  margin: 12px 24px;
}

.chat-widget {
  padding: 24px;
  width: 400px;
  display: flex;
  flex-direction: column;
}

.message-box {
  flex-grow: 1;
  overflow-x: hidden;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  padding-top: 16px;
}

.control-box {
  display: flex;
  justify-content: flex-end;
  min-height: 34px;
  border-bottom: 1px solid #ccc;
  padding-bottom: 12px;
}

.control-box > input {
  margin-right: auto;
}

.chat-box {
  display: flex;
  border-top: 1px solid #ccc;
  padding-top: 12px;
  min-height: 32px;
  position: relative;
}

.chat-box > input {
  flex-grow: 1;
  padding-right: 75px;
}

.chat-box > button {
  position: absolute;
  right: 0;
  height: 33px;
}

input {
  display: inline-block;
  padding: 10px 0 10px 15px;
  font-weight: 400;
  color: #377D6A;
  background: #efefef;
  border: 0;
  border-radius: 16px;
  outline: 0;
  transition: all .3s ease-out;
}

input:focus,
input:active {
  color: #377D6A;
  background: #fff;
}

.message {
  display: flex;
  width: fit-content;
  font-size: 14px;
  min-height: min-content;
}

.message > p {
  min-width: 40px;
  max-width: 230px;
  margin: 0;
  margin-bottom: 12px;
  position: relative;
  display: inline-block;
  padding: 0 10px;
  width: auto;
  height: fit-content;
  line-height: 28px;
  border-radius: 40px;
  z-index: 1;
  word-break: break-all;
}

.message > p:before {
  content: "";
  position: absolute;
  z-index: -1;
  display: block;
  width: 22px;
  height: 22px;
  border-radius: 0 30px 0 30px;
}

.message > p:after {
  content: "";
  position: absolute;
  display: block;
  width: 22px;
  height: 22px;
  border-radius: 0 30px 0 30px;
  background: #fafbfd;
  z-index: -1;
}

.my-message {
  align-self: flex-end;
  margin-right: 16px;
}

.my-message > p {
  color: #F6F6F6;
  background: #651fff;
}

.my-message > p:before {
  bottom: 0px;
  right: -8px;
  background: #651fff;
}

.my-message > p:after {
  bottom: 1px;
  right: -18px;
  transform: rotate(30deg);
}

.received-message {
  align-self: flex-start;
  margin-left: 16px;
  flex-direction: column;
}

.received-message > p {
  color: #F6F6F6;
  background: #4caf50;
}

.received-message > p:before {
  bottom: 0px;
  left: -8px;
  background: #4caf50;
  transform: rotate(90deg);
}

.received-message > p:after {
  bottom: 1px;
  left: -18px;
  transform: rotate(60deg);
}

.received-message > span {
  font-size: 11px;
  margin-left: 8px;
  margin-bottom: 4px;
  color: #777;
}

.webrtc-media {
  overflow: auto;
  display: flex;
  flex-direction: column;
  padding: 24px 0;
}

#local {
  order: -1;
  border: 1px solid #4caf50;
}